昨天我們裝了 django-extensions
這個套件,為了是方便今天我們觀察 Query N+1 的問題,Query N+1 問題會造成 API 與資料庫很大的資源消耗,所以要盡可能地避免這件事情發生,今天讓我們一起看看是什麼問題以及怎麼避免吧!
為了方便觀察以及統一,讓我們先調整一下資料庫中的資料,讓我們先開啟 Django Shell(別忘了先啟動虛擬環境)
python manage.py shell_plus
接著在裡面貼入下方程式碼,將舊資料刪除
Task.objects.all().delete()
Tag.objects.all().delete()
Category.objects.all().delete()
以上的指令會將 Task, Tag 和 Category 的所有資料清除。
接著讓我們來建立測試用的資料,我們一樣在 Django Shell 中貼入下方的程式碼
c01 = Category.objects.create(name=f"C01")
c02 = Category.objects.create(name=f"C02")
c03 = Category.objects.create(name=f"C03")
t01 = Tag.objects.create(name="T01")
t02 = Tag.objects.create(name="T02")
t03 = Tag.objects.create(name="T03")
task01 = Task.objects.create(title="Task01", category=c01)
task01.tags.add(t01)
task02 = Task.objects.create(title="Task02", category=c02)
task02.tags.add(t01)
task03 = Task.objects.create(title="Task03", category=c02)
task03.tags.add(t01, t02)
task04 = Task.objects.create(title="Task04", category=c03)
task04.tags.add(t01, t02)
task05 = Task.objects.create(title="Task05", category=c03)
task05.tags.add(t01, t02, t03)
task06 = Task.objects.create(title="Task06", category=c03)
task06.tags.add(t01, t02, t03)
這邊建立了三個分類、三個標籤以及六個任務,並將他們關聯起來
我們先把 server 跑起來,並讓他會輸出 SQL 指令
python manage.py runserver_plus --print-sql
接著讓我們來觀察一下使用 GET 方法呼叫 http://127.0.0.1:8000/api/todo/tasks 的時候會執行哪些 SQL
大家會看到一堆的 SQL,實在是太多了,這邊就不貼上了,但如果剛好懂 SQL 的大家可以觀察一下,會發現他重複的撈了很多次的分類以及標籤。
Django 在執行這段語法的做法是這樣的,他會先撈出所有的任務,接著再次一次一次的去找任務一的分類、任務一的標籤、任務二的分類、任務二的標籤以此類推,那這樣可以發現這會造成程式需要不斷去發起資料庫查詢去將需要的東西查回來,而他的有一個關聯時的查詢字數會是 1 次全部任務接著看任務有幾筆,假設為 n 筆,那他會去查詢這 n 筆的關聯物件(標籤或是分類),所以會造成有一個關聯時產生一個列表需要有 n+1 次的查詢,所以才會被叫做 Query N+1 問題。
而這邊我們的任務有兩個關聯所以就會變成 2n+1 次的查詢,隨著資料的筆數越來越多就會查詢越來越多次,這會對系統造成很大的負擔(API 與 DB 都是),現在讓我們看看怎麼解決他。
在 Django 中我們要解決 Query N+1 問題需要先釐清問題的方向到底是多筆還是單筆,以現在這個例子來說要解決 Category 的 N+1 問題就是單筆的,因為一個 Task 只會需要撈出一個分類,而 Tag 就是多筆的,因為一個 Task 有可能會有多筆的標籤。
接下來我們就可以記得一個簡單的判斷方式,如果單筆用 select_related 多筆用 prefetch_related ,下方我們會說明這兩個指令該如何使用,並簡單的說明一下背後的原理,如果想瞭解更多還是大家參考文件。
先讓我們改一下 server/app/todo/views.py
檔案
# ...... 以上省略 ......
class TaskViewSet(viewsets.ModelViewSet):
- queryset = todo_models.Task.objects.order_by("id")
+ queryset = (
+ todo_models.Task.objects.order_by("id")
+ .select_related("category")
+ .prefetch_related("tags")
+ )
serializer_class = todo_serializers.TaskSerializer
ordering_fields = ("id", "title")
search_fields = ("title", "description")
filterset_fields = {
"is_finish": ("exact",),
"tags__name": ("exact",),
"id": ("gt", "gte", "lt", "lte"),
"title": ("contains", "icontains"),
}
# ...... 以下省略 ......
上面的異動是我們要針對 category 欄位進行 select_related 並針對 tags 欄位進行 prefetch_related,暫時不明白沒關係,先讓我們看一下效果。
現在我們先再次使用 GET 方法請求 http://127.0.0.1:8000/api/todo/tasks 並觀察他的 SQL
應該會發現只剩下下面三個
SELECT COUNT(*) AS "__count"
FROM "todo_task"
這個是因為我們有實作分頁,所以需要資料總數,所以他先撈取了我們總更有多少任務比數。
SELECT "todo_task"."id",
"todo_task"."title",
"todo_task"."description",
"todo_task"."is_finish",
"todo_task"."end_at",
"todo_task"."created_at",
"todo_task"."updated_at",
"todo_task"."category_id",
"todo_category"."id",
"todo_category"."name"
FROM "todo_task"
INNER JOIN "todo_category"
ON ("todo_task"."category_id" = "todo_category"."id")
ORDER BY "todo_task"."id" ASC
LIMIT 6
這個是撈取所有的任務以及分類,這邊他跟前面不同的是他在撈取任務時使用了 JOIN 的方式將分類的資訊也一並撈出,這樣就只需要撈一次就好了,這個就是 select_related 的效果。
SELECT ("todo_task_tags"."task_id") AS "_prefetch_related_val_task_id",
"todo_tag"."id",
"todo_tag"."name",
"todo_tag"."description"
FROM "todo_tag"
INNER JOIN "todo_task_tags"
ON ("todo_tag"."id" = "todo_task_tags"."tag_id")
WHERE "todo_task_tags"."task_id" IN (3, 4, 5, 6, 7, 8)
這個是撈取當前任務所需要的標籤,他就不會分次的去撈任務一的標籤任務二的標籤,而是一次撈出當前列表要顯示的任務有用到的標籤,這個就是 prefetch_related 的效果。
初學者在學習 Django 時很容易遇到這個效能問題,這個效能問題很容易遇到,但同時也很容易解決。大家可以多觀察 SQL 指令盡可能避免自已的程式遇到這個問題。或是也可以透過 nplusone 這個工具來自動的偵測這個問題,有興趣的人可以自己研究一下這個套件。
結束前別忘了檢查一下今天的程式碼有沒有問題,並排版好喔。
ruff check --fix .
black .
pyright .
今天的內容就到這邊了,讓我們期待明天的內容吧。
P.S. 今天的檔案更新可以參考我的 Git Commit 大家可以搭配服用